feat(appkit): tools(plugins) DX, runAgent plugins arg, shared toolkit-resolver#305
Merged
MarioCadenas merged 12 commits intomainfrom May 8, 2026
Merged
feat(appkit): tools(plugins) DX, runAgent plugins arg, shared toolkit-resolver#305MarioCadenas merged 12 commits intomainfrom
MarioCadenas merged 12 commits intomainfrom
Conversation
This was referenced Apr 21, 2026
3c7c35e to
cb7fe2b
Compare
162e970 to
29e3534
Compare
cb7fe2b to
0afea5e
Compare
29e3534 to
b462716
Compare
0afea5e to
983461c
Compare
539487e to
dac73b5
Compare
a7b0444 to
623792d
Compare
624f2a0 to
0dd07a4
Compare
623792d to
2f752a0
Compare
41fa8b0 to
8b0c28e
Compare
c2b0f28 to
fd73087
Compare
22393bb to
fdfd568
Compare
269d1a9 to
c038a77
Compare
fdfd568 to
65178cf
Compare
c038a77 to
ab5b485
Compare
65178cf to
03da825
Compare
ab5b485 to
6378638
Compare
03da825 to
c16fa29
Compare
6378638 to
2640ea4
Compare
c16fa29 to
a8cedbd
Compare
pkosiec
reviewed
May 7, 2026
…esolver
DX centerpiece. Introduces the symbol-marker pattern that collapses
plugin tool references in code-defined agents from a three-touch dance
to a single line, and extracts the shared resolver that the agents
plugin, auto-inherit, and standalone runAgent all now go through.
`packages/appkit/src/plugins/agents/from-plugin.ts`. Returns a spread-
friendly `{ [Symbol()]: FromPluginMarker }` record. The symbol key is
freshly generated per call, so multiple spreads of the same plugin
coexist safely. The marker's brand is a globally-interned
`Symbol.for("@databricks/appkit.fromPluginMarker")` — stable across
module boundaries.
`packages/appkit/src/plugins/agents/toolkit-resolver.ts`. Single source
of truth for "turn a ToolProvider into a keyed record of `ToolkitEntry`
markers". Prefers `provider.toolkit(opts)` when available (core plugins
implement it), falls back to walking `getAgentTools()` and synthesizing
namespaced keys (`${pluginName}.${localName}`) for third-party
providers, honoring `only` / `except` / `rename` / `prefix` the same
way.
Used by three call sites, previously all copy-pasted:
1. `AgentsPlugin.buildToolIndex` — fromPlugin marker resolution pass
2. `AgentsPlugin.applyAutoInherit` — markdown auto-inherit path
3. `runAgent` — standalone-mode plugin tool dispatch
Before the existing string-key iteration, `buildToolIndex` now walks
`Object.getOwnPropertySymbols(def.tools)`. For each `FromPluginMarker`,
it looks up the plugin by name in `PluginContext.getToolProviders()`,
calls `resolveToolkitFromProvider`, and merges the resulting entries
into the per-agent index. Missing plugins throw at setup time with a
clear `Available: ...` listing — wiring errors surface on boot, not
mid-request.
`hasExplicitTools` now counts symbol keys too, so a
`tools: { ...fromPlugin(x) }` record correctly disables auto-inherit
on code-defined agents.
- `AgentTools` type: `{ [key: string]: AgentTool } & { [key: symbol]:
FromPluginMarker }`. Preserves string-key autocomplete while
accepting marker spreads under strict TS.
- `AgentDefinition.tools` switched to `AgentTools`.
`packages/appkit/src/core/run-agent.ts`. When an agent def contains
`fromPlugin` markers, the caller passes plugins via
`RunAgentInput.plugins`. A local provider cache constructs each plugin
and dispatches tool calls via `provider.executeAgentTool()`. Runs as
service principal (no OBO — there's no HTTP request). If a def
contains markers but `plugins` is absent, throws with guidance.
`fromPlugin`, `FromPluginMarker`, `isFromPluginMarker`, `AgentTools`
added to the main barrel.
- 14 new tests: marker shape, symbol uniqueness, type guard,
factory-without-pluginName error, fromPlugin marker resolution in
AgentsPlugin, fallback to getAgentTools for providers without
.toolkit(), symbol-only tools disables auto-inherit, runAgent
standalone marker resolution via `plugins` arg, guidance error when
missing.
- Full appkit vitest suite: 1311 tests passing.
- Typecheck clean.
Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
…on A rewrite normalize-result, consume-adapter-stream, tool-dispatch were extracted to core/agent/ but agents.ts still imported them from plugins/agents/. Update the import paths to match the final file locations.
Flips the layering: agent types, helpers, and the standalone runner now
live in core/agent/ instead of plugins/agents/. The HTTP-facing agents()
plugin still owns its routes/streaming/threads but no longer re-exports
framework primitives that peer plugins depend on.
Moved (with git mv to preserve history):
- plugins/agents/{types,from-plugin,build-toolkit,toolkit-resolver,
consume-adapter-stream,normalize-result,tool-dispatch,system-prompt,
load-agents}.ts -> core/agent/
- plugins/agents/tools/{tool,define-tool,function-tool,hosted-tools,
sql-policy,json-schema,index}.ts -> core/agent/tools/
- core/{run-agent,create-agent-def}.ts -> core/agent/{run-agent,create-agent}.ts
- 14 corresponding test files -> core/agent/tests/
Stayed in plugins/agents/ (HTTP/route concerns):
- agents.ts, event-channel.ts, event-translator.ts, tool-approval-gate.ts,
thread-store.ts, schemas.ts, defaults.ts, manifest.json, index.ts
Updated imports across analytics, files, genie, lakebase to source from
core/agent/ directly. plugins/agents/index.ts stays as a back-compat
barrel that re-exports the moved primitives, so the public package
surface (@databricks/appkit) is byte-identical.
Verified: tsc --noEmit clean, 1581/1581 appkit tests pass.
Extracts `composePromptForAgent` + `normalizeAutoInherit` into plugins/agents/prompt.ts and `printRegistry` into plugins/agents/registry-printer.ts. These were free-function helpers at the bottom of agents.ts with no dependency on plugin state — pure candidates for extraction. Also opens the door for the bigger split (route handlers and `_streamAgent`/`runSubAgent` extracted into routes/*.ts and tool-execution.ts) by relaxing the access modifier on plugin members those modules will need (`agents`, `activeStreams`, `mcpClient`, `threadStore`, `approvalGate`, `resolvedApprovalPolicy`, `resolvedLimits`, `countUserStreams`). All marked `@internal` to keep the public surface unchanged. Note: the full split into `routes/` and `tool-execution.ts` proposed in plans/agent-architecture-followup.md is deferred. Route handlers and `_streamAgent`/`runSubAgent` remain as methods on AgentsPlugin because they have heavy plugin-state coupling and cross-call patterns (`runSubAgent` recurses, `_handleChat` calls `_streamAgent`, etc.) that don't translate cleanly to free functions without a larger refactor. Tracked as a follow-up. agents.ts: 1262 -> 1212 lines (-50). The plan's aspirational target of <=280 isn't met because the per-route extraction pass is deferred, but the helper extraction + access-modifier relaxation lays the groundwork. Verified: tsc --noEmit clean, 1589/1589 appkit tests pass.
…te manifest) Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
…ebase Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
PR #305 agentic-review fixes (P1 + cheap P2): - _handleChat / _handleInvocations now wrap threadStore calls in try/catch and surface failures as a 500. Without this an unreachable store hung the SSE client until the upstream proxy timeout because the async Express handler propagated the rejection without a response. - runAgent rejects hosted/MCP tools at index-build time with a clear pointer to createApp({ plugins: [..., agents()] }). Previously the adapter saw the tool list and the failure surfaced mid-conversation. - Extract applyToolkitOptions: single source of truth for the prefix/only/except/rename filtering shared by build-toolkit (registry path) and toolkit-resolver (getAgentTools fallback). Bug fixes here apply to both paths instead of drifting between them. - resolveStandaloneProvider now uses an isStandaloneToolProvider type guard instead of `instance as unknown as ToolProvider`. Distinct from core/plugin-context.isToolProvider which also requires asUser (request-scoped, only meaningful inside createApp). Tests added: - threadStore failure paths on /chat (get/create/addMessage rejections) and /invocations (create + addMessage mid-loop) — assert 500 responses with the canonical error body. - runAgent rejects hosted tools at standalone resolution. - runAgent surfaces a clear error when fromPlugin references a plugin lacking ToolProvider methods. - runAgent recursively executes sub-agents declared on def.agents. #4 (countUserStreams O(n)) deferred — n bounded by 5 × users in the default config; not a hot path. #11 (printRegistry console.log) and Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com> #9-#15 advisory items left as-is per the PR-comment thread.
Drop the symbol-keyed fromPlugin(factory) API in favor of a function
form on AgentDefinition.tools that receives a typed plugins map. The
key win: each plugin appears exactly once in user code (only inside
createApp({ plugins: [...] })), and plugins.<name>.toolkit() is fully
autocompleted in the IDE.
Before:
import { analytics, fromPlugin } from "@databricks/appkit";
const support = createAgent({
tools: { ...fromPlugin(analytics) },
});
await createApp({ plugins: [analytics(), agents({ agents: { support } })] });
After:
const support = createAgent({
tools(plugins) {
return { ...plugins.analytics.toolkit() };
},
});
await createApp({ plugins: [analytics(), agents({ agents: { support } })] });
The dual tools: AgentTools | AgentToolsFn shape means simple agents
keep the plain object form. The function runs once at agent setup; its
return record replaces def.tools for the rest of the registered
agent's lifetime.
Typing relies on a RegisteredPlugins module-augmentation interface that
core plugins (analytics, files, genie, lakebase) extend at their
declaration sites. Third-party plugins fall back to a
PluginToolkitProvider shape via the index signature; they still work at
runtime, just without keyed autocomplete.
Standalone runAgent invokes the function form against a Plugins map
built lazily from RunAgentInput.plugins. Plugin instances are
constructed on first plugins.<name>.toolkit() call and cached, so the
same instance handles both spread-time and dispatch-time work.
Auto-inherit semantics unchanged: declaring tools (object or function,
even an empty record) opts out, mirroring the prior rule.
Markdown agents are untouched. YAML cannot carry a function reference,
so toolkits: [analytics] and ambient agents({ tools: {...} }) keep
working as the markdown surface.
fromPlugin, FromPluginMarker, FROM_PLUGIN_MARKER, FromPluginSpread,
isFromPluginMarker, and the NamedPluginFactory.pluginName field
(its only consumer) are deleted from beta exports and from-plugin.ts.
Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
…ugin augmentation Two follow-ups on the tools(plugins) function form: 1. Each entry in the Plugins map is now typed as PluginToolkitProvider (just the .toolkit() method) instead of the full plugin class. Inside tools(plugins), plugins.analytics.<TAB> shows only .toolkit() — the contract for tool composition — instead of leaking query, name, setup, injectRoutes, and every other instance method. 2. RegisteredPlugins ships pre-populated with the four core plugin keys (analytics, files, genie, lakebase) directly in core/agent/types.ts. The per-plugin "declare module ../../core/agent/types" blocks at the bottom of each core plugin file are gone. Adding a new core plugin now means adding one line to RegisteredPlugins, not duplicating an augmentation block. Third-party plugins still augment RegisteredPlugins from user code if they want their key in autocomplete; the index-signature fallback keeps unaugmented names working at runtime. Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
…ed record
Hardcoding the registered plugin names in core/agent/types.ts was the
wrong shape — AppKit cannot statically know which plugins the
surrounding createApp call will register, so a curated list there was
guaranteed to drift.
Drop the RegisteredPlugins augmentation interface entirely. Plugins is
now Record<string, PluginToolkitProvider>: every entry exposes
.toolkit() at runtime, but plugin names do not autocomplete inside
tools(plugins). Users refer to plugins by the same name they pass to
createApp({ plugins: [...] }).
Plugin-name autocomplete (so tools(plugins) sees plugins.<TAB> ->
analytics | files | ...) is a separate, larger design problem with
real tradeoffs across inline-in-createApp, factory pattern, codegen,
and user augmentation. Tracked separately; this commit unblocks the
v5 stack with a runtime-correct, honest type that does not lie about
what AppKit knows.
Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
countUserStreams() previously walked every entry in activeStreams on every /chat and /invocations request — O(n) over total concurrent streams across all users on the hot path. At scale that scan dominates chat-request latency under load. Add userStreamCounts: Map<string, number> kept in sync with activeStreams via two new helpers: - trackStream(requestId, userId, controller) — sole writer that registers a stream and increments the user's counter. - untrackStream(requestId) — sole writer that removes a stream and decrements the counter; deletes the user's entry on the last stream to keep the map bounded across many distinct users; idempotent on unknown ids. countUserStreams() is now an O(1) Map.get(). All three previous mutation sites (_streamAgent setup, _streamAgent finally, _handleCancel) go through the helpers, so the counter cannot drift from the map. Tests: existing dos-limits seeders updated to use trackStream(); new test in dos-limits.test.ts asserts the invariant across track/untrack for multiple users plus idempotent untrack. Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
Address all four medium findings from the xavier review panel (correctness + security + performance) on PR #305. 1. applyToolkitOptions no longer returns literal "undefined" The rename branch used Object.hasOwn(rename, name) followed by an unguarded rename[name] return. When a developer wrote `rename: { query: featureFlag ? "x" : undefined }`, the explicit undefined satisfied hasOwn() and propagated as the toolkit key, producing a tool keyed literally "undefined" downstream. Drop hasOwn, use nullish coalescing + empty-string guard so explicit undefined and "" both fall through to the prefix path. Adds a new toolkit-options.test.ts covering all four knobs (prefix, only, except, rename) plus the regression case. 2. runAgent shares providerCache across sub-agent recursion Sub-agent tool dispatch used to call runAgent recursively, which built a fresh providerCache inside buildStandaloneToolIndex on every nested call. Plugins were re-instantiated per sub-agent invocation, in-instance state diverged between parent and child, and any per-call setup cost (pool open, client construction) ran for every nested level. Hoist the cache to the top-level runAgent and thread it through a private runAgentInternal() helper so all nested calls share the same instance map. 3. Plugin lookup names the missing plugin (proxy) The Plugins type is Record<string, PluginToolkitProvider> without noUncheckedIndexedAccess workspace-wide, so unknown keys typecheck as present but resolve to undefined at runtime. Accessing .toolkit() on undefined produced a generic "Cannot read properties of undefined (reading 'toolkit')" with no plugin name and no list of available plugins. New core/agent/plugins-map.ts exports createPluginsProxy() — wraps the resolved record so unknown string-key access throws a named error: "<context> referenced plugin '<name>', but it is not registered. Available: ...". Used by both standalone runAgent and the agents plugin's buildPluginsMap. Symbol access and well-known probes (then, toJSON, toString, valueOf, constructor) pass through untouched so Promise/JSON tooling doesn't trip the guard. 4. Standalone runAgent eagerly initialises plugin lifecycle resolveStandaloneProvider used to construct plugin instances lazily on first plugins[name].toolkit() call and never invoke attachContext()/setup(). First-party plugins like AnalyticsPlugin that depend on createApp's runtime (WorkspaceClient, ServiceContext, PluginContext) crashed mid-stream when their tools dereferenced getWorkspaceClient(), with stack traces far from the cause. The previous JSDoc only hinted with "(when they work at all)". New initStandalonePlugins() helper runs at the top of runAgent: constructs every plugin in input.plugins, calls attachContext({}), awaits setup(), and populates the shared cache. Failures wrap with a clear message naming the plugin and pointing the caller at createApp({ plugins: [..., agents(...)] }). Plugins with the default no-op setup() initialise cleanly; plugins that need createApp-only runtime fail at startup, not mid-conversation. The runAgent JSDoc is rewritten to spell out the trust boundary (no OBO, no approval gate, treat as trusted-prompt environment) and the new init contract. Tests: + 8 toolkit-options unit tests, + 3 run-agent tests covering the new behaviours (proxy names missing plugin, setup failure surfaces at entry not mid-stream, sub-agent recursion shares plugin instance with parent — verified by counting constructor calls and asserting same instance id from both parent and child tools). 2284 tests passing across the workspace. Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
pkosiec
approved these changes
May 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DX centerpiece. Introduces a function-form
toolsfield onAgentDefinitionthat receives a plugins map, eliminating the needto mention each plugin twice in user code. Extracts the shared
toolkit resolver that the agents plugin, auto-inherit, and standalone
runAgentall now go through. Refactors the agent runtime intocore/agent/so peer plugins can depend on it without crossing thesibling boundary.
tools(plugins)— the function formAgentDefinition.toolsaccepts either a plain record (for agentsthat only use inline tools) or a function
(plugins) => Record<string, AgentTool>that receives a plugins map and returns atool record. The function runs once at agent setup; the result is
cached as the agent's resolved tool record.
Each plugin is mentioned exactly once, in
createApp({ plugins: [...] }). No held variables, no spread markers, nofromPluginindirection.
resolveToolkitFromProvider(pluginName, provider, opts?)packages/appkit/src/core/agent/toolkit-resolver.ts. Single sourceof truth for "turn a
ToolProviderinto a keyed record ofToolkitEntrymarkers". Prefersprovider.toolkit(opts)whenavailable (core plugins implement it), falls back to walking
getAgentTools()and synthesizing namespaced keys(
${pluginName}.${localName}) for third-party providers, honoringonly/except/rename/prefixthe same way.Used by three call sites, previously all copy-pasted:
AgentsPlugin.buildToolIndex— function-form resolution passAgentsPlugin.applyAutoInherit— markdown auto-inherit pathrunAgent— standalone-mode plugin tool dispatchThe underlying filtering primitive
applyToolkitOptions(incore/agent/toolkit-options.ts) is also used bybuildToolkitEntriesincore/agent/build-toolkit.ts.runAgentgainsplugins?: PluginData[]packages/appkit/src/core/agent/run-agent.ts. When an agent def'stoolsis the function form,runAgentbuilds a plugins map lazilyfrom
RunAgentInput.plugins. Plugin instances are constructed onthe first
plugins.<name>.toolkit(...)call and cached, so the sameinstance handles both spread-time resolution and dispatch-time tool
execution. Hosted/MCP tools are rejected up front with a clear error
rather than failing mid-conversation when the adapter tries to
dispatch them — they require a live MCP client that only exists
inside the agents plugin's lifecycle.
Agent runtime relocated to
core/agent/The framework-level agent primitives (
createAgent,runAgent,tool helpers, types, system-prompt composition, markdown agent
loader) moved from
plugins/agents/tocore/agent/. TheHTTP-facing
agents()plugin inplugins/agents/consumes thesebut no longer owns them — peer plugins (analytics, files, genie,
lakebase) can depend on the runtime without reaching across the
sibling boundary.
Type plumbing
AgentToolssimplified toRecord<string, AgentTool>(no symbolkeys, no
FromPluginMarker).AgentToolsFn = (plugins: Plugins) => AgentTools.AgentDefinition.tools?: AgentTools | AgentToolsFn.Plugins = Record<string, PluginToolkitProvider>— plainstring-keyed map; users refer to plugins by the name they pass to
createApp({ plugins: [...] }). Per-plugin keyed autocomplete onthe
pluginsparameter is a separate, larger design problemtracked for follow-up.
DoS-limit hardening
maxConcurrentStreamsPerUser)enforced on both
/chatand/invocations(previously only on/chat).countUserStreamsnow O(1) via a per-user counter map kept insync with
activeStreamsthroughtrackStream/untrackStreamhelpers.
RunStateso sub-agent dispatches enforce the same limits astop-level calls.
limits.toolCallTimeoutMs, default 5min)combined with parent abort signal via
AbortSignal.any.threadStore.{get,create,addMessage}failures in_handleChatand
_handleInvocationsreturn HTTP 500 instead of hanging theclient connection.
Reload safety
AgentsPlugin.reload()builds a fresh registry first and only swapson success, so a malformed markdown file or missing tool reference
no longer leaves the live registry in a half-rebuilt state. Markdown
agent loader
fs.*calls are now async to avoid blocking the eventloop during reload.
Exports
Function-form types (
AgentToolsFn,Plugins,PluginToolkitProvider) exported from@databricks/appkit/beta.fromPlugin,FromPluginMarker,isFromPluginMarker,FROM_PLUGIN_MARKER,FromPluginSpread, and theNamedPluginFactory.pluginNamefield (its only consumer) areremoved — files deleted, callers migrated.
Test plan
plugins.<name>.toolkit(), function-form opt-out fromauto-inherit (object and empty-object forms), function form with
provider lacking
.toolkit(), function form invoked exactly onceat setup, sub-agent recursive execution in standalone
runAgent,hosted-tool rejection at index-build time,
threadStorefailurepaths returning HTTP 500 (5 paths in
route-handler-errors.test.ts),/invocationshonouringmaxConcurrentStreamsPerUser, and theuserStreamCountsinvariant across track/untrack lifecycles.docs:buildclean.PR Stack
agents()plugin +createAgent(def)+ markdown-driven agents — feat(appkit): agents() plugin, createAgent(def), and markdown-driven agents #304tools(plugins)DX +runAgentplugins arg + toolkit-resolver (this PR)Demo
agent-demo.mp4